﻿/*
 * Copyright 2011 Edward L. Stauff.                     Contact: <EdStauff@gmail.com>.
 * 
 * This file, "CmdLineParser.cs", is part of the "Stauffware.Common.Console" class 
 * library.  "Stauffware.Common" is free software: you can redistribute it and/or 
 * modify it under the terms of the GNU Lesser Public License as published by the 
 * Free Software Foundation, either version 3 of the License, or (at your option) 
 * any later version.
 * 
 * "Stauffware.Common" is distributed in the hope that it will be useful, 
 * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser Public License for more 
 * details.
 * 
 * You should have received a copy of the GNU Lesser Public License along with 
 * "Stauffware.Common".  If not, see <http://www.gnu.org/licenses/>.
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.Configuration;
using System.Reflection;
using Stauffware.Common.Core;


namespace Stauffware.Common.Console
{
	/// <summary>
	/// Maintains command-line parameter definitions 
	/// and handles the parsing of the command-line.
	/// </summary>

	public sealed class CmdLineParser
	{
		#region Properties & Fields

		/// <summary>
		/// The switch character.
		/// </summary>
		public readonly char SwitchChar = '-';

		/// <summary>
		/// Help overview for the entire application.
		/// </summary>
		public string ApplicationHelp = "";

		/// <summary>
		/// Gets or sets a list of the "help" parameter names.
		/// By default this list contains "-?" and "-help".
		/// </summary>
		public ICollection<string> HelpNames
		{
			get
			{
				List<string> list = new List<string>();
				foreach (CmdLineParam p in this.UnscopedParams.Values)
					if (p is HelpParam)
						list.Add(p.Name);
				return list;
			}
			set
			{
				Require.NotNull(value);
				Require.Condition(value.Count > 0, "You must define at least one help parameter name.");

				foreach (string name in this.HelpNames)
					this.UnscopedParams.Remove(name);
				foreach (string name in value)
					this.AddParam(new HelpParam(this.DummyGroup, name));
			}
		}

		/// <summary>
		/// If non-null, a Help parameter will, after displaying help, exit the program  with
		/// this exit code.  If null, Help parameters will not automatically exit the program.
		/// </summary>
		public AppExitCode HelpExitCode = ConsoleApp.ExitCodes.HelpOnly;

		/// <summary>
		/// Where to write help output.  This defaults to System.Console.Out.
		/// If set to null, it will reset itself to System.IO.TextWriter.Null.
		/// </summary>
		/// <remarks>
		/// Why would you ever do this, you ask?  In the case where the your application
		/// gets launched outside the context of a console window, you should use a
		/// StringWriter to catch the help output and display it in a MessageBox.
		/// </remarks>
		public System.IO.TextWriter HelpWriter
		{
			get { return this._helpWriter; }
			set { this._helpWriter = (value != null) ? value : System.IO.TextWriter.Null; }
		}
		private System.IO.TextWriter _helpWriter = System.Console.Out;

		/// parameter groups that have names (does not include unnamed groups)
		internal readonly PrefixDictionary<CmdLineParamGroup> NamedParamGroups = 
									new PrefixDictionary<CmdLineParamGroup>(true);

		/// parameters that aren't name-scoped to a parameter group
		internal readonly PrefixDictionary<CmdLineParam> UnscopedParams = 
									new PrefixDictionary<CmdLineParam>(true);

		/// All the positional parameters.  
		/// After parameter collection is complete, this list will be in order.
		internal readonly List<CmdLineParam> PositionalParams = new List<CmdLineParam>();

		// a cache for the value parsers we've created
		//private readonly Dictionary<Type, CmdLineValueParserDlgt> ValueParsers =
		//                            new Dictionary<Type, CmdLineValueParserDlgt>();

		private readonly CmdLineParamGroup DummyGroup;

		private static readonly char[] Dot = ".".ToCharArray();

		internal readonly ValueParserFactory ValueParserFactory = new ValueParserFactory();

		#endregion

		#region Setup Methods 

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Creates an object that can parse command-line parameters, as defined by the
		/// fields and properties of the given object(s).
		/// </summary>
		/// <param name="paramsObjects">
		///		One or more objects containing fields and properties to receive 
		///		command-line parameter values.
		/// </param>

		public CmdLineParser (params object[] paramsObjects)
		{
			this.DummyGroup = new CmdLineParamGroup(this);
			this.AddParam(new HelpParam(this.DummyGroup, "-help"));
			this.AddParam(new HelpParam(this.DummyGroup, "-?"));

			foreach (object obj in paramsObjects)
				this.AddParams(obj, null, null);

			this.AddParamGroup(new BuiltInParamGroup(this));
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Adds command-line parameters as defined by the fields and properties of the given object.
		/// </summary>

		public void AddParams (object paramsObject)
		{
			this.AddParams(paramsObject, null, null);
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Adds command-line parameters as defined by the fields and properties of the 
		/// given objects.  Use this when you want to associate command-line parameters
		/// with members of a class to which you can't add custom attributes.
		/// </summary>
		/// <param name="paramsObj">
		///		The object that will receive command-line parameter values.
		/// </param>
		/// <param name="groupAttr">
		///		If not null, this is equivalent to having attached a CmdLineGroupAttribute
		///		to the paramsObj class.
		/// </param>
		/// <param name="memberAttrs">
		///		If not null, this dictionary maps field and property names to CmdLineArgAttribute
		///		objects, which is equivalent to having attached CmdLineArgAttribute objects
		///		to the those fields and properties (of the paramsObj class).
		/// </param>
		public void AddParams (object paramsObj, CmdLineGroupAttribute groupAttr,
								IDictionary<string, CmdLineArgAttribute> memberAttrs)
		{
			CmdLineParamGroup group = new CmdLineParamGroup(this, paramsObj, groupAttr, 
															memberAttrs, this.PositionalParams);
			this.AddParamGroup(group);
		}

		//-----------------------------------------------------------------------------

		private void AddParamGroup (CmdLineParamGroup group)
		{
			if (string.IsNullOrEmpty(group.GroupName))
				this.ApplicationHelp += group.GroupHelp;
			else this.NamedParamGroups.Add(group.GroupName, group);
		}

		//-----------------------------------------------------------------------------
		///// <summary>
		///// Adds a value parser to be used for command-line parameter values.
		///// If one is already defined for the given type, it is replaced.
		///// </summary>
		///// <remarks>
		///// Use this method to register your own value parsers for standard types, or
		///// for types not handled by CmdLineParser.
		///// </remarks>

		//public void AddValueParser (Type t, CmdLineValueParserDlgt parser)
		//{
		//    this.ValueParsers[t] = parser;
		//}

		//-----------------------------------------------------------------------------

		internal void AddParam (CmdLineParam p)
		{
			if (!this.UnscopedParams.TryAdd(p.Name, p))
				throw new Assertion("duplicate CmdLineArg name: " + 
												ErrorUtils.ValueToString(p.Name));

		//	if (p.IsSwitch)
		//		this.SomeSwitchesDefined = true;
		}

		//-----------------------------------------------------------------------------
		// checks for missing or duplicate positional parameters

		private void ValidatePositionalParamDefinitions ( )
		{
			this.PositionalParams.Sort(delegate(CmdLineParam a, CmdLineParam b)
			{ return a.Position.CompareTo(b.Position); });

			for (int i = 0; i < this.PositionalParams.Count; i++)
			{
				if (this.PositionalParams[i].Position > (i + 1))
					throw new Exception("positional parameter #" + 
										(i+1).ToString() + " has not been defined");
				if (this.PositionalParams[i].Position < (i + 1))
					throw new Exception("positional parameter #" + 
						this.PositionalParams[i].Position.ToString() + " defined more than once");
			}
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Returns a parser delegate that can be used with the given type, or null if
		/// one couldn't be found.
		/// </summary>

		internal ICmdLineValueParser GetValueParser (Type valueType)
		{
			ICmdLineValueParser parser = this.ValueParserFactory.GetParser(valueType);
			Assert.NotNull(parser, "couldn't create a parser for " + valueType.ToString());
			return parser;
		}

		//-----------------------------------------------------------------------------

		#endregion

		#region Parsing Methods

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Parses the command line, placing values in the fields and properties of
		/// the objects provided to the constructor and AddParams.  
		/// Throws a CmdLineSyntaxError if any errors are found.  
		/// Exits the application if help is requested.
		/// </summary>
		/// <param name="cmdLineArgs">what was passed to main()</param>
		/// <exception cref="CmdLineSyntaxError"/>

		public void ParseCmdLine (IList<string> cmdLineArgs)
		{
			this.ValidatePositionalParamDefinitions();

			ArgValueList argValues = new ArgValueList(cmdLineArgs);

			int posValueCount = 0;	// number of positional parameter arguments found so far

			while (argValues.Remaining > 0)
				this.ParseNextArg(argValues, ref posValueCount);

			this.CheckRequiredArgs();
		}

		//-----------------------------------------------------------------------------
		// helper for ParseCmdLine

		private void ParseNextArg (ArgValueList argValues, ref int posValueCount)
		{
			CmdLineParam clp;
			CmdLineSyntaxError err;

			// see if the next arg contains an '=' 
			int equalSignPos = argValues[0].IndexOf('=');

			if (equalSignPos == 0)
				throw new CmdLineSyntaxError("expecting parameter name before '='; " + 
								"spaces must appear on both sides of '=', or neither side");

			// look for a named parameter with an embedded '=' (e.g. "name=value")
			if (equalSignPos >= 0)
			{
				string name = argValues[0].Substring(0, equalSignPos);
				err = this.FindParam(name, out clp);
				if (err == null)
				{
					clp.ProcessArgs(name, argValues);
					return;
				}
			}
			else
			{
				err = this.FindParam(argValues[0], out clp);
				if (err == null)
				{
					argValues.Next();
					clp.ProcessArgs(argValues[-1], argValues);
					return;
				}
			}

			// couldn't treat it as a named param, so treat it as a positional param

			if (this.PositionalParams.Count == 0)
				throw err;
			else
			{
				if (posValueCount >= this.PositionalParams.Count)
					throw new CmdLineSyntaxError("extraneous parameter(s): \"" + argValues[0] + "\"", err);

				posValueCount += this.PositionalParams[posValueCount].ProcessArgs(argValues);
			}
		}

		//-----------------------------------------------------------------------------

		private void CheckRequiredArgs ( )
		{
			ListString missing = new ListString(", ");

			foreach (CmdLineParam p in this.UnscopedParams.Values)
				if (p.IsRequired && !p.Found)
					missing.Add(p.Name);

			foreach (CmdLineParamGroup g in this.NamedParamGroups.Values)
				g.CheckRequiredArgs(missing);

			if (missing.Count > 0)
				throw new CmdLineSyntaxError("missing required parameter(s): " + missing.ToString());
		}

		#endregion

		#region Other Methods

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Returns the parameter with the given name.  Throws an exception if the
		/// parameter or group name is undefined or ambiguous.
		/// </summary>

		public ICmdLineParam GetParam (string paramName, string groupName=null)
		{
			if (groupName == null)
				return this.UnscopedParams[paramName];
			CmdLineParamGroup group = this.NamedParamGroups[groupName];
			return group.AllParams[paramName];
		}

		//-----------------------------------------------------------------------------

		internal int CountParams (Type paramType)
		{
			int count = 0;
			foreach (CmdLineParam clp in this.UnscopedParams.Values)
				if (clp.EffectiveValueType == paramType)
					count++;
			foreach (CmdLineParamGroup grp in this.NamedParamGroups.Values)
				foreach (CmdLineParam clp in grp.AllParams.Values)
					if (clp.EffectiveValueType == paramType)
						count++;

			return count;
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Tries to look up a parameter.
		/// </summary>
		/// <param name="name">the parameter name, including its group name, if any</param>
		/// <param name="clp">receives the found parameter object, or null</param>
		/// <returns>null on success, or an error object</returns>

		internal CmdLineSyntaxError FindParam (string name, out CmdLineParam clp)
		{
			PrefixMatch pm;

			if (this.NamedParamGroups.Count > 0)
			{
				int dot = name.IndexOf('.');
				if (dot > 0)
					return this.FindParam(name.Substring(0, dot), name.Substring(dot+1), out clp);
			}

			pm = this.UnscopedParams.Lookup(name, out clp);
			if (pm == PrefixMatch.Ambiguous)
				return new CmdLineSyntaxError("ambiguous parameter name: \"" + name + "\"");
			if (pm == PrefixMatch.None)
				return new CmdLineSyntaxError("unknown parameter name: \"" + name + "\"");

			Ensure.NotNull(clp);
			return null;
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Tries to look up a parameter.
		/// </summary>
		/// <param name="groupName">the parameter group name, or null</param>
		/// <param name="paramName">the parameter name</param>
		/// <param name="clp">receives the found parameter object, or null</param>
		/// <returns>null on success, or an error object</returns>

		private CmdLineSyntaxError FindParam (string groupName, string paramName, out CmdLineParam clp)
		{
			clp = null;

			CmdLineParamGroup group;
			PrefixMatch pm = this.NamedParamGroups.Lookup(groupName, out group);
			if (pm == PrefixMatch.Ambiguous)
				return new CmdLineSyntaxError("ambiguous group name: \"" + groupName + "\"");
			if (pm == PrefixMatch.None)
				return new CmdLineSyntaxError("unknown group name: \"" + groupName + "\"");

			pm = group.LookupParam(paramName, out clp);
			if (pm == PrefixMatch.Ambiguous)
				return new CmdLineSyntaxError("ambiguous parameter name: \"" + paramName + 
											"\" + is ambiguous in group \"" + groupName);
			if (pm == PrefixMatch.None)
				return new CmdLineSyntaxError("parameter name \"" + paramName + 
											"\" + not found in group \"" + groupName);
			Ensure.NotNull(clp);
			return null;
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Logs all the parameters and their values.
		/// </summary>
		/// <param name="tw">where to write the information</param>
		/// <param name="allParams">
		/// whether to include parameters that weren't provided on the command line
		/// </param>
		/// <param name="linePrefix">prepended to each line</param>

		public void LogParams (System.IO.TextWriter tw, bool allParams, string linePrefix=null)
		{
			foreach (CmdLineParam clp in this.UnscopedParams.Values)
			{
				if (clp.Found || allParams)
					clp.Log(tw, linePrefix);
			}
		}

		//-----------------------------------------------------------------------------

		#endregion
	}

}
